Conversation
BREAKING CHANGES: - React 19 support: On React 19+, <Helmet> renders native JSX elements that React hoists to <head>, instead of manual DOM manipulation. HelmetProvider becomes a transparent passthrough. The existing API is fully compatible — no code changes required for consumers. - TypeScript: HelmetServerState no longer includes a titleAttributes field (it was never populated — title attributes were always bundled inside the title datum). HelmetDatum.toComponent() return type changed from React.Component<any> to React.ReactElement[]. MappedServerState is no longer exported from the package entry point. - Package manager: Switched from yarn to pnpm. - Package exports: Added exports field with proper import/require conditions. Renamed typings to types in package.json. React 19 changes: - New React19Dispatcher renders <title>, <meta>, <link>, <base>, <script>, <style>, <noscript> as actual JSX elements - Runtime version detection via React.version - htmlAttributes/bodyAttributes still applied via direct DOM manipulation (no React 19 equivalent) - HelmetProvider is a no-op passthrough on React 19+ - helmetData prop and SSR context are not used on React 19 - prioritizeSeoTags has no effect on React 19 TypeScript cleanup: - Dedicated tsconfig.build.json for declaration emission - All @ts-ignore comments removed (5 total), replaced with proper types - Internal Helmet methods marked private (cleaner .d.ts output) - Explicit public type exports instead of wildcard re-export - Fixed incorrect types: HelmetDatum.toComponent(), setHelmet() nullability - Removed legacy IE styleSheet API branch in client.ts Peer dependencies now include React 19: react: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- Remove .circleci/config.yml - Add .github/workflows/ci.yml running tests, type checks, and lint across Node 18, 20, and 22 using pnpm - Update README badge from CircleCI to GitHub Actions
- SSR e2e (vitest, Node environment): renders pages with renderToString and verifies helmet context output for title, meta, link, base, style, script, noscript, html/body attributes, titleTemplate, nested components - Browser e2e (Playwright + Vite dev server): navigates to pages in a real browser and verifies tags appear in <head>, html/body attributes are set, title is correct, nested components resolve correctly - Shared fixture app with 4 pages: meta (declarative), title-template, api (prop-style), nested (multiple Helmet instances) - CI: SSR e2e runs in the build matrix, browser e2e runs as a separate job with Playwright chromium Scripts: test:e2e:server — SSR tests via vitest test:e2e:browser — browser tests via Playwright test:e2e — both test:all — unit + e2e
There was a problem hiding this comment.
Pull request overview
This PR releases v3.0.0 of react-helmet-async, adding a React 19+ runtime path that renders native <title>/<meta>/<link>/... elements for React-managed head hoisting, while keeping the existing React 16–18 behavior intact. It also modernizes the TypeScript/build setup and switches the repo tooling/CI from yarn + CircleCI to pnpm + GitHub Actions, adding E2E coverage.
Changes:
- Add React 19 detection and a new dispatcher that renders native head elements (plus html/body attribute handling).
- TypeScript/build cleanup (new
tsconfig.build.json, declaration emission changes, exported type surface adjustments). - Tooling/CI overhaul: pnpm migration, GitHub Actions CI, and new Vitest/Playwright E2E tests.
Reviewed changes
Copilot reviewed 36 out of 40 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Excludes build/e2e outputs from unit test runs. |
| tsconfig.json | Uses build config as a base; configures typecheck-only settings for dev/tests. |
| tsconfig.build.json | New build TS config for declaration-only output to lib/. |
| src/types.ts | Updates public types (notably toComponent() return type; server state shape). |
| src/reactVersion.ts | Adds runtime React major-version detection for React 19 path selection. |
| src/index.tsx | Switches to explicit type exports; routes React 19 to React19Dispatcher. |
| src/client.ts | Removes legacy IE stylesheet handling; replaces @ts-ignore with typed access. |
| src/React19Dispatcher.tsx | New React 19 dispatcher: renders native elements + manages html/body attrs. |
| src/Provider.tsx | Makes provider a passthrough on React 19; retains HelmetData on 16–18. |
| src/HelmetData.ts | Updates setHelmet typing to allow null; keeps SSR bootstrap behavior. |
| src/Dispatcher.tsx | Adjusts types for setHelmet(null) and removes @ts-ignore usage. |
| package.json | Bumps to 3.0.0; adds exports map; switches scripts to pnpm; adds e2e deps. |
| e2e/vitest.config.ts | Adds node-environment Vitest config for SSR E2E tests. |
| e2e/server.test.tsx | Adds SSR E2E assertions for rendered HTML + Helmet server outputs. |
| e2e/playwright.config.ts | Adds Playwright config + webServer wiring for browser E2E. |
| e2e/fixtures/vite.config.ts | Adds Vite fixture server config for browser E2E runs. |
| e2e/fixtures/index.html | Adds minimal HTML template for fixture app. |
| e2e/fixtures/entry-server.tsx | Adds SSR entry that returns HTML + helmet context for tests. |
| e2e/fixtures/entry-client.tsx | Adds client entry to mount the fixture app for browser E2E. |
| e2e/fixtures/App.tsx | Fixture app pages covering declarative/API Helmet usage + nesting. |
| e2e/browser.test.ts | Browser E2E coverage validating head tags + html/body attributes. |
| tests/react19/utils.tsx | Test helpers for the mocked React 19 path under React 18. |
| tests/react19/title.test.tsx | Unit tests for React 19 title behavior (API + declarative). |
| tests/react19/style.test.tsx | Unit tests for React 19 style tag rendering. |
| tests/react19/script.test.tsx | Unit tests for React 19 script tag rendering. |
| tests/react19/provider.test.tsx | Unit tests for provider passthrough behavior on React 19. |
| tests/react19/noscript.test.tsx | Unit tests for React 19 noscript rendering. |
| tests/react19/meta.test.tsx | Unit tests for React 19 meta rendering + updates. |
| tests/react19/link.test.tsx | Unit tests for React 19 link rendering + updates. |
| tests/react19/htmlAttributes.test.tsx | Unit tests for React 19 htmlAttributes DOM application/cleanup. |
| tests/react19/combined.test.tsx | Integration-style unit tests combining multiple tag types/instances. |
| tests/react19/bodyAttributes.test.tsx | Unit tests for React 19 bodyAttributes DOM application/cleanup. |
| tests/react19/base.test.tsx | Unit tests for React 19 base tag rendering. |
| README.md | Documents React 19 behavior changes + pnpm-based development/test commands. |
| .husky/commit-msg | Updates commit-msg hook to use pnpm. |
| .gitignore | Ignores Playwright outputs (test-results, playwright-report). |
| .github/workflows/ci.yml | Adds GitHub Actions CI (typecheck/lint/unit + SSR e2e + browser e2e). |
| .circleci/config.yml | Removes legacy CircleCI config. |
Comments suppressed due to low confidence (4)
src/React19Dispatcher.tsx:44
- applyAttributes tracks managed html/body attributes using a new hardcoded "data-rh-managed" marker, while the rest of the library uses the shared HELMET_ATTRIBUTE ("data-rh") for attribute bookkeeping. Using a different marker makes cleanup inconsistent across code paths and complicates interop with existing behavior. Consider reusing HELMET_ATTRIBUTE (or at least centralizing the marker in constants) so html/body attribute management is consistent.
src/Provider.tsx:42 - HelmetProvider’s context typing still assumes
helmet?: HelmetServerState, but on the clientsetHelmet(null)is now valid. Consider updating ProviderProps.context/helmet typing to allow null so consumers don’t need unsafe casts and the runtime behavior is accurately reflected in the public types.
src/Dispatcher.tsx:55 - Dispatcher.emitChange relies on a double-cast (
as MappedServerStatethenas unknown as HelmetServerState) to satisfy types. This suppresses real mismatches between reducePropsToState/mapStateOnServer and HelmetServerState and can hide runtime issues. Prefer fixing the underlying types/return values (e.g., make mapStateOnServer explicitly return HelmetServerState and ensure all toComponent() implementations match) so this cast is unnecessary.
if (Provider.canUseDOM) {
handleStateChangeOnClient(state);
} else if (mapStateOnServer) {
serverState = mapStateOnServer(state as MappedServerState) as unknown as HelmetServerState;
}
setHelmet(serverState);
tests/react19/provider.test.tsx:36
- This test defines
const context = {}but never passes it to HelmetProvider/HelmetData, so the assertion will always pass regardless of provider behavior. To actually verify React 19 behavior, render through (or the relevant API entry point) and then assert on context.helmet.
it('does not populate context with helmet state', () => {
const context = {} as any;
// Render through the provider with context
render(
<Helmet>
<title>Test</title>
</Helmet>
);
// On React 19, context.helmet should not be populated
// (the provider is a passthrough, no server state is set)
expect(context.helmet).toBeUndefined();
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| setHelmet: (serverState: HelmetServerState | null) => { | ||
| this.context.helmet = serverState!; | ||
| }, |
There was a problem hiding this comment.
HelmetData.value.setHelmet now accepts null but assigns it into this.context.helmet via a non-null assertion (serverState!). This makes the type lie and can leave context.helmet as null at runtime. Update HelmetDataContext (and related public context typings) to allow null and assign serverState directly without the non-null assertion.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| export interface HelmetDatum { | ||
| toString(): string; | ||
| toComponent(): React.Component<any>; | ||
| toComponent(): React.ReactElement[]; | ||
| } |
There was a problem hiding this comment.
HelmetDatum.toComponent is now typed to return React.ReactElement[]. Ensure all implementations comply—for example, server-side priority.toComponent() currently returns undefined when prioritizeSeoTags is false, which would violate this type and can break consumers calling .toComponent().
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
src/React19Dispatcher.tsx
Outdated
| componentWillUnmount() { | ||
| // Clean up html/body attributes | ||
| applyAttributes(TAG_NAMES.HTML, {}); | ||
| applyAttributes(TAG_NAMES.BODY, {}); | ||
| } |
There was a problem hiding this comment.
React19Dispatcher’s html/body attribute handling isn’t instance-aware: applyAttributes() writes directly to the single /, and componentWillUnmount blindly clears managed attributes. With multiple instances, unmounting an inner one can remove attributes that should fall back to an outer instance (since the outer may not re-render). Consider reintroducing shared instance bookkeeping for html/body attributes (similar to reducePropsToState in the legacy path) so updates/unmounts compute the correct merged attributes instead of clearing globally.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
src/React19Dispatcher.tsx
Outdated
| init() { | ||
| this.applyNonHostedAttributes(); | ||
| } | ||
|
|
||
| render() { | ||
| this.init(); | ||
|
|
||
| return React.createElement( |
There was a problem hiding this comment.
React19Dispatcher is performing DOM mutations during render via init() → applyNonHostedAttributes(). Side effects in render can break concurrent rendering/StrictMode and can run on the server unexpectedly. Move this work to componentDidMount (and keep it in componentDidUpdate), and remove init() from render.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
…tion Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
…oTags is false Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
|
@copilot tests are failing after I merged your changes - fix them, also update the GitHub action run on all branches, not just main |
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
Co-authored-by: staylor <52932+staylor@users.noreply.github.com>
|
Thanks for the update
Does this mean that we lose the title deduplication feature that I described here? |
@slorber According to the research done by @birdofpreyru you do indeed lose title deduplication. Read more in the README here https://github.com/birdofpreyru/react-helmet#drpogodinreact-helmet-vs-react-helmet-async |
3.0.0
React 19 Support
On React 19+,
<Helmet>renders native JSX elements (<title>,<meta>,<link>,<base>,<script>,<style>,<noscript>) that React automatically hoists to<head>, instead of manual DOM manipulation.HelmetProviderbecomes a transparent passthrough.The existing API is fully compatible — no code changes required for consumers upgrading from v2.
React.versionhtmlAttributes/bodyAttributesstill applied via direct DOM manipulation (no React 19 equivalent)helmetDataprop, SSRcontext,prioritizeSeoTags, andonChangeClientStateare not used on the React 19 pathOn React 16–18, behavior is completely unchanged.
Breaking Changes
HelmetServerState.titleAttributesremoved — this field was never populated bymapStateOnServer(title attributes were always bundled inside thetitledatum). If you were referencinghelmet.titleAttributes, it was always undefined.HelmetDatum.toComponent()return type changed fromReact.Component<any>toReact.ReactElement[]to match what was actually returned.MappedServerStateis no longer exported from the package entry point (internal type).HelmetData.setHelmet()now acceptsnull(matches actual runtime behavior).TypeScript Cleanup
tsconfig.build.jsonfor declaration emission (replaces fragile rawtscCLI invocation)@ts-ignorecomments removed, replaced with proper typesHelmetclass methods markedprivate— cleaner.d.tsoutputexport * from './types'styleSheetAPI branch inclient.tstsconfig.jsoncleaned up: explicit include/exclude,ESNextmodulePackage Changes
"typings"→"types"(modern field name)"exports"field with properimport/require/typesconditionspeerDependenciesnow includes^19.0.0Tests
61 new test cases covering the React 19 code path (mocking
isReact19totrue):titleTemplate,defaultTitle)All 298 tests pass (237 original + 61 new).